퍼센트 인코딩(URL Encoding) — 문자를 %XX로 바꾸고 되돌리기
NOTE
URL/쿼리 문자열에 그대로 넣을 수 없는 문자(공백, 한글, 특수문자)를
%XX(16진수 두 자리) 형태로 바꾸는 퍼센트 인코딩과, 그걸 다시 원래 문자로 복원하는 디코딩을 직접 구현하며 원리를 정리한다.
0. 왜 필요한가
URL은 영문·숫자와 일부 기호만 안전하게 담을 수 있다. 공백이나 한글, &/= 같은 URL 문법에서 특별한 의미를 갖는 문자를 값 안에 그대로 넣으면 파싱이 깨진다. 이런 문자를 안전한 문자만으로 표현하는 표준 방법이 퍼센트 인코딩이다 — 안 되는 문자를 % 뒤에 그 문자의 바이트 값을 16진수 두 자리로 붙여서 표현한다(예: 공백 → %20).
1. 인코딩 — 안전한 문자는 그대로, 나머지는 %XX로
static int is_safe_char(char c)
{
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|| c == '-' || c == '.' || c == '_';
}
int url_encode(const char *src, char *dst, unsigned int dst_len)
{
unsigned int out = 0;
for (; *src != '\0'; src++) {
if (is_safe_char(*src)) {
out += snprintf(dst + out, dst_len - out, "%c", *src);
} else {
out += snprintf(dst + out, dst_len - out, "%%%02X", (unsigned char)*src);
}
}
dst[out] = '\0';
return out;
}%02X는 “16진수 두 자리로, 자리가 모자라면 앞에 0을 채운다”는 포맷이다. 항상 정확히 두 글자가 나오도록 자리를 고정해야, 디코딩할 때 % 다음 두 글자를 고민 없이 고정 길이로 읽어낼 수 있다.
2. 디코딩 — %를 만나면 다음 두 글자를 16진수로 해석
static int hex_digit_value(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return (c - 'A') + 10;
if (c >= 'a' && c <= 'f') return (c - 'a') + 10;
return -1; /* 16진수 문자가 아님 */
}
int url_decode(const char *src, char *dst, unsigned int dst_len)
{
unsigned int out = 0;
for (; *src != '\0' && out < dst_len - 1; src++) {
if (src[0] == '%' && src[1] != '\0' && src[2] != '\0') {
int hi = hex_digit_value(src[1]);
int lo = hex_digit_value(src[2]);
if (hi < 0 || lo < 0) {
return -1; /* %XX 형식이 아님 — 잘못된 입력으로 처리 */
}
dst[out++] = (char)((hi << 4) | lo);
src += 2;
} else {
dst[out++] = *src;
}
}
dst[out] = '\0';
return out;
}핵심은 '0''9', 'A''F', 'a'15 사이의 숫자 값이 나온다('f'가 아스키 코드 안에서 각각 연속된 값이라는 성질이다. 문자에서 그 구간의 시작 문자를 빼면 0'7' - '0' = 7, 'C' - 'A' + 10 = 12). 16진수 두 자리는 “앞자리 값 × 16 + 뒷자리 값”으로 합치는데, hi << 4(왼쪽으로 4비트 시프트)가 정확히 hi * 16과 같은 값이다 — 16진수 한 자리가 4비트를 표현하므로, 한 자리를 올리는 건 4비트만큼 왼쪽으로 미는 것과 동일하다.
3. 자주 하는 실수
dst_len을 넘는지 확인하지 않고 계속 채우기 —url_encode에서snprintf는 잘려도 실제로 필요했던 길이를 리턴하므로,out이dst_len을 넘어선 뒤에도 계속 그 값에 더하면 다음snprintf호출의 크기 계산(dst_len - out)이 음수가 되어(부호 없는 타입이면 거대한 양수로 뒤집혀서) 버퍼 밖에 쓰게 될 수 있다. 매 반복마다 남은 공간을 확인해야 한다.- 소문자 16진수(
%2f등)를 처리 안 하기 — 표준(RFC 3986)은%XX의 대소문자를 구분하지 않는다. 디코딩 쪽에서 대문자A~F만 처리하고 소문자를 빼먹으면, 다른 시스템이 소문자로 인코딩해서 보낸 값을 못 알아본다. %뒤에 문자가 두 개 안 남은 경우(문자열이 중간에 끝난 경우)를 검사 안 하기 —src[1],src[2]가 이미 문자열의 널 종단을 지나쳤는데 그대로 읽으면 범위 밖 메모리를 읽는다. 2번 예제처럼%를 만났을 때 그 뒤 두 글자가 실제로 존재하는지부터 확인해야 한다.- 안전 문자 집합을 표준과 다르게 잡기 — 어떤 문자를 “안전하다”고 볼지는 URL의 어느 부분(경로, 쿼리 값 등)이냐에 따라 미묘하게 다르다. 너무 좁게 잡으면 불필요하게 인코딩되는 문자가 늘고, 너무 넓게 잡으면 실제로 예약된 의미를 가진 문자(
&,=등)가 인코딩 없이 값 안에 섞여 들어가 파싱이 깨질 수 있다.